Appearance
实战-视图 - View
上一章我们聊了一系列比较抽象的统计函数,了解了它们是如何改变几何图形的位置的。这一章我们来谈谈视图,了解一下什么是视图,又该如何组合它们去搭建一个拥有很多视图的界面(如下图)。
首先我们还是从视图理论开始,然后进入实战部分,去实现几个视图组合函数,最后再进行简单的小结。那么废话不多说,我们马上进入视图的学习。
视图理论
在可视化中,一个完整的视图至少由两个部分构成:几何元素和辅助组件(如下图。
上面的这张图是一个单视图图表,因为它只有一个视图。而下面这张图是一个多视图图表,因为它将多个视图组合在了一起。
对于多视图图表来说,它其实是由一棵视图树构成。视图树类似于前端开发中的 DOM 树,只不过每一个节点不是一个 DOM 而是一个视图,类似的概念还有渲染引擎中的场景图(Scene Graph)。
在数据结构层面,视图树就是一个如下的嵌套对象。
javascript
const viewTree = {
type: 'layer',
children: [
{
type: 'row',
children: [{/.../}, {/.../}]
},
{
type: 'interval',
}
]
};
用 TypeScript 可以如下简单定义。
ts
type ViewTree = {
type?: string, // 当前节点的种类
children?: ViewTree[], // 包含的孩子节点
[key: string]: any,
}
不同的视图节点有不同的布局方法和功能,在这里我们将实现3种视图节点:
- 图层(Layer):
type: 'layer'
- 弹性盒子(Flex):
type: 'col | row'
- 分面(Facet):
type: 'facet'
而视图这个模块要做的就是解析这棵视图树,计算出每个视图区域的位置和大小,以及在这个区域有几个视图(视图可以重叠,之后我们会看见)。
接下来我们就正式进入开发环节。
创建视图(createViews)
视图模块就导出一个函数:createViews(tree, computes)。
该函数有两个参数,第一个参数是一个描述视图树的嵌套对象,第二个参数是一个对象,该对象的每一个值是一个支持的视图计算函数。该函数返回一个数组,数组的每一个元素是一个元组。
元组的第一个元素是一个如下的区域的描述:
ts
type Area = {
x: number,
y: number,
width: number,
height: number,
[key:string]: any
}
元组的第二个元素是一个数组,数组的每一个元素是当前区域的视图:
ViewTree[]
。
接下来我们就来看代码的实现。
javascript
// src/view/view.js
import { computeFlexViews } from './flex';
import { computeFacetViews } from './facet';
import { computeLayerViews } from './layer';
import { descendants, group } from '../utils';
// 支持 4 种类型的节点
export function createViews(root, computes = {
layer: computeLayerViews,
col: computeFlexViews,
row: computeFlexViews,
facet: computeFacetViews,
}) {
// 获得视图树的所有节点
const nodes = descendants(root);
// 计算根节点视图区域大小
const { width = 640, height = 480, x = 0, y = 0 } = root;
const rootView = { width, height, x, y };
// 根据节点索引视图
const nodeView = new Map([[root, rootView]]);
for (const node of nodes) {
const view = nodeView.get(node);
const { children = [], type } = node;
const computeChildrenViews = computes[type];
if (computeChildrenViews) {
// 计算孩子节点的区域大小
const childrenViews = computeChildrenViews(view, node);
if (computeChildrenViews !== computeFacetViews) {
// 如果不是分面节点,孩子节点和计算出来的区域一一对应
for (const [i, child] of Object.entries(children)) {
nodeView.set(child, childrenViews[i]);
}
} else {
// 如果是分面节点,将产生一些新的孩子节点
for (const child of children) {
for (const view of childrenViews) {
nodeView.set({ ...child }, view);
}
}
}
}
}
// 将计算好的视图根据区域去分组
const key = (d) => `${d.x}-${d.y}-${d.width}-${d.height}`;
const keyViews = group(Array.from(nodeView.entries()), ([, view]) => key(view));
return Array.from(keyViews.values()).map((views) => {
const view = views[0][1];
const nodes = views.map((d) => d[0]);
return [view, nodes];
});
}
javascript
// src/utils/tree.js
export function descendants(root) {
const nodes = [];
const push = (d) => nodes.push(d);
bfs(root, push);
return nodes;
}
// 使用宽度优先搜索遍历这棵树
export function bfs(root, callback) {
const discovered = [root];
while (discovered.length) {
const node = discovered.pop();
callback(node);
discovered.push(...(node.children || []));
}
}
javascript
// src/utils/index.js
export * from './tree';
图层(Layer)
图层(Layer)节点的孩子视图都是重合在一起的,该节点主要用于在一个区域中绘制多个几何元素,就比如如下的例子。
javascript
const temperatures = [
{ month: 'Jan', city: 'Tokyo', temperature: 7 },
{ month: 'Jan', city: 'London', temperature: 3.9 },
{ month: 'Feb', city: 'Tokyo', temperature: 6.9 },
{ month: 'Feb', city: 'London', temperature: 4.2 },
{ month: 'Mar', city: 'Tokyo', temperature: 9.5 },
{ month: 'Mar', city: 'London', temperature: 5.7 },
{ month: 'Apr', city: 'Tokyo', temperature: 14.5 },
{ month: 'Apr', city: 'London', temperature: 8.5 },
{ month: 'May', city: 'Tokyo', temperature: 18.4 },
{ month: 'May', city: 'London', temperature: 11.9 },
{ month: 'Jun', city: 'Tokyo', temperature: 21.5 },
{ month: 'Jun', city: 'London', temperature: 15.2 },
{ month: 'Jul', city: 'Tokyo', temperature: 25.2 },
{ month: 'Jul', city: 'London', temperature: 17 },
{ month: 'Aug', city: 'Tokyo', temperature: 26.5 },
{ month: 'Aug', city: 'London', temperature: 16.6 },
{ month: 'Sep', city: 'Tokyo', temperature: 23.3 },
{ month: 'Sep', city: 'London', temperature: 14.2 },
{ month: 'Oct', city: 'Tokyo', temperature: 18.3 },
{ month: 'Oct', city: 'London', temperature: 10.3 },
{ month: 'Nov', city: 'Tokyo', temperature: 13.9 },
{ month: 'Nov', city: 'London', temperature: 6.6 },
{ month: 'Dec', city: 'Tokyo', temperature: 9.6 },
{ month: 'Dec', city: 'London', temperature: 4.8 },
];
我们用折线图将上面的数据可视化得到如下的结果,可以发现在该图区域中有两种几何元素:线和点,它们的辅助元素可以简单理解为重合了。
对于如下的视图树,绘出来时如下的结果。
javascript
const viewTree = {
type: 'layer',
children: [{}, {}],
};
const views = createViews(viewTree);
它的实现非常简单,把自己的区域大小返回作为孩子节点的区域就好。
javascript
// src/view/layer.js
export function computeLayerViews(box, node) {
const { children = [] } = node;
return new Array(children.length).fill(0).map(() => ({ ...box }));
}
弹性盒子(Flex)
弹性盒子(Flex)节点的孩子视图在水平或者竖直方向都是充满父容器的,该概念来自于前端开发中的 Flex 布局。
该节点主要用于将区域划分为子区域,不同的子区域拥有不同的视图。该节点节点又分为两种类型:row 和 col。比如如果将上面例子中的容器类型从 layer 改成 row 的话就会得到如下的结果。
对于如下的视图树,绘出来有如下的结果。
javascript
const viewTree = {
type: 'row', // 孩子水平排列
flex: [1, 1], // 指定孩子的节点区域的比例
padding: 40, // 孩子节点区域的间距
children: [
{},
// 孩子竖直排列
{ type: 'col', children: [{}, {}] },
],
};
const views = createViews(viewTree);
具体的代码实现如下:
javascript
// src/view/flex.js
export function computeFlexViews(box, node) {
const { type, children, flex = children.map(() => 1), padding = 40 } = node;
const [mainStart, mainSize, crossSize, crossStart] = type === 'col'
? ['y', 'height', 'width', 'x']
: ['x', 'width', 'height', 'y'];
const sum = flex.reduce((total, value) => total + value);
const totalSize = box[mainSize] - padding * (children.length - 1);
const sizes = flex.map((value) => totalSize * (value / sum));
const childrenViews = [];
for (let next = box[mainStart], i = 0; i < sizes.length; next += sizes[i] + padding, i += 1) {
childrenViews.push({
[mainStart]: next,
[mainSize]: sizes[i],
[crossStart]: box[crossStart],
[crossSize]: box[crossSize],
});
}
return childrenViews;
}
分面(Facet)
最后我们来看看分面(Facet)节点。分面是可视化中一个常用的手段,主要用于对数据进行分组,不同组的数据在不同的视图中展示。
javascript
const facet = [
{ city: 'London', month: 'Jan.', rainfall: 18.9, type: 'a' },
{ city: 'London', month: 'Feb.', rainfall: 28.8, type: 'a' },
{ city: 'London', month: 'Mar.', rainfall: 39.3, type: 'a' },
{ city: 'London', month: 'Apr.', rainfall: 81.4, type: 'a' },
{ city: 'London', month: 'May', rainfall: 47, type: 'b' },
{ city: 'London', month: 'Jun.', rainfall: 20.3, type: 'b' },
{ city: 'London', month: 'Jul.', rainfall: 24, type: 'b' },
{ city: 'London', month: 'Aug.', rainfall: 35.6, type: 'b' },
{ city: 'Berlin', month: 'Jan.', rainfall: 12.4, type: 'a' },
{ city: 'Berlin', month: 'Feb.', rainfall: 23.2, type: 'a' },
{ city: 'Berlin', month: 'Mar.', rainfall: 34.5, type: 'a' },
{ city: 'Berlin', month: 'Apr.', rainfall: 99.7, type: 'a' },
{ city: 'Berlin', month: 'May', rainfall: 52.6, type: 'b' },
{ city: 'Berlin', month: 'Jun.', rainfall: 35.5, type: 'b' },
{ city: 'Berlin', month: 'Jul.', rainfall: 37.4, type: 'b' },
{ city: 'Berlin', month: 'Aug.', rainfall: 42.4, type: 'b' },
];
比如如果如上的数据 x 方向按照 type 字段分组,y 方向按照 city 字段分组就会得到如下的结果。不难发现:在左上角的条形图中只包含 city === "Berlin"
以及 type === "a"
的数据。
根据上面的案例我们可以发现,分面节点除了会计算孩子节点区域的大小,还会去过滤数据,所以它还会额外返回一个过滤数据的函数。使用方式如下:
javascript
const data = [
{ sex: 'male', skin: 'white' },
{ sex: 'male', skin: 'black' },
{ sex: 'female', skin: 'white' },
{ sex: 'female', skin: 'yellow' },
];
const views = createViews({
type: 'facet',
// 通过 encodings 这个配置去指定 x 和 y 方向的分组
encodings: {
x: 'sex', // 根据 sex 属性分组
y: 'skin', // 根据 skin 属性分组
},
data,
padding: 20, // 指定区域之间的间隔
children: [{}, {}],
});
const [, [view]] = views;
const { transform } = view; // 获取过滤数据的函数
transform(data); // [{ sex: 'male', skin: 'white' }]
最后绘制出来的效果如下:
代码实现如下:
javascript
// src/view/flex.js
import { group } from '../utils';
export function computeFacetViews(box, {
data, encodings = {}, padding = 0,
paddingLeft = 45, paddingRight = 45, paddingBottom = 45, paddingTop = 60,
}) {
const { x, y } = encodings;
const cols = x ? Array.from(group(data, (d) => d[x]).keys()) : [undefined];
const rows = y ? Array.from(group(data, (d) => d[y]).keys()) : [undefined];
const n = cols.length;
const m = rows.length;
const views = [];
const width = box.width - paddingLeft - paddingRight;
const height = box.height - paddingTop - paddingBottom;
const boxWidth = (width - padding * (n - 1)) / n;
const boxHeight = (height - padding * (m - 1)) / m;
for (let i = 0; i < n; i += 1) {
for (let j = 0; j < m; j += 1) {
const transform = (data) => {
const inRow = (d) => d[x] === cols[i] || cols[i] === undefined;
const inCol = (d) => d[y] === rows[j] || rows[j] === undefined;
return data.filter((d) => inRow(d) && inCol(d));
};
views.push({
x: paddingLeft + box.x + padding * i + i * boxWidth,
y: paddingRight + box.y + padding * j + j * boxHeight,
width: boxWidth,
height: boxHeight,
transform, // 指定过滤函数
});
}
}
return views;
}
本章的所有视图计算函数就开发完成了,完整的代码可以在这里浏览,同样也可以通过这里的测试代码来验证代码的正确性。
小结
分面这一章就到这里结束了,我们通过这一章了解到了如下的几种视图组合方式:
- 图层(Layer)
- 弹性盒子(Flex)
- 分面(Facet)
不同的组合方式有不同的使用场景,它可以帮助我们搭建复杂的可视化图表界面,也可以提高我们的分析能力。
到目前为止,我们已经把渲染引擎和低级可视化模块开发完成了。接下来是时候将它们串联起来了,看看在整个渲染流程中它们该如何使用。下一章我们将进入 Sparrow 渲染流程 Plot 模块的开发。